<?php declare(strict_types=1);
/**
 * @author      xianganyall <xianganyall@gmail.com>
 * @copyright   2023-2025 owner
 **/

namespace Srv\Libs\Plugins\Jwt;

use ArrayAccess;
use DomainException;
use InvalidArgumentException;
use UnexpectedValueException;

class JWT
{
    public const ASN1_INTEGER           = 0x02;
    public const ASN1_SEQUENCE          = 0x10;
    public const ASN1_BIT_STRING        = 0x03;
    public static int $leeway           = 0;        // 检查 nbf 允许的时间误差
    public static int $timestamp        = 0;        // 初始时间,默认当前时间
    
    public static array $supportedAlgs  = [         // 支持算法
        'ES256'     => ['openssl', 'SHA256'],
        'HS256'     => ['hash_hmac', 'SHA256'],
        'HS384'     => ['hash_hmac', 'SHA384'],
        'HS512'     => ['hash_hmac', 'SHA512'],
        'RS256'     => ['openssl', 'SHA256'],
        'RS384'     => ['openssl', 'SHA384'],
        'RS512'     => ['openssl', 'SHA512'],
    ];

    /**
     * @param string $jwt
     * @param $key
     * @param array $allowedAlgs
     * @param array $payLoadSrc
     * @return mixed
     * Decodes a JWT string into a PHP object
     */
    public static function decode(string $jwt, $key, array $allowedAlgs = [], array &$payLoadSrc = [])
    {
        $timestamp = self::$timestamp < 1 ? time() : self::$timestamp;
        if (empty($key)) {
            throw new InvalidArgumentException('Key may not be empty');
        }
        $tks    = explode('.', $jwt);
        if (count($tks) != 3) {
            throw new UnexpectedValueException('Wrong number of segments');
        }
        list($headb64, $bodyb64, $cryptob64) = $tks;
        if(null === ($header = self::jsonDecode(self::urlsafeB64Decode($headb64)))){
            throw new UnexpectedValueException('Invalid header encoding');
        }
        $payLoadSrc['head']     = $header;
        if(null === ($payload = self::jsonDecode(self::urlsafeB64Decode($bodyb64)))){
            throw new UnexpectedValueException('Invalid claims encoding');
        }
        $payLoadSrc['body']     = $payload;
        $sig                    = self::urlsafeB64Decode($cryptob64);
        if(strlen($sig) < 1){
            throw new UnexpectedValueException('Invalid signature encoding');
        }
        if(empty($header->alg)){
            throw new UnexpectedValueException('Empty algorithm');
        }
        if(empty(self::$supportedAlgs[$header->alg])){
            throw new UnexpectedValueException('Algorithm not supported');
        }
        if(!in_array($header->alg, $allowedAlgs)){
            throw new UnexpectedValueException('Algorithm not allowed');
        }
        if($header->alg === 'ES256'){
            $sig = self::signatureToDER($sig);
        }
        if(is_array($key) || $key instanceof ArrayAccess){
            if(isset($header->kid)){
                if(!isset($key[$header->kid])){
                    throw new UnexpectedValueException('"kid" invalid, unable to lookup correct key');
                }
                $key = $key[$header->kid];
            }else{
                throw new UnexpectedValueException('"kid" empty, unable to lookup correct key');
            }
        }
        if(!self::verify($headb64.'.'.$bodyb64, $sig, $key, $header->alg)){
            throw new UnexpectedValueException('Signature verification failed');
        }
        if(isset($payload->nbf) && $payload->nbf > ($timestamp + self::$leeway)){
            throw new UnexpectedValueException('Cannot handle token prior to '.date('Y-m-d\TH:i:sO', $payload->nbf));
        }
        if(isset($payload->iat) && $payload->iat > ($timestamp + self::$leeway)){
            throw new UnexpectedValueException('Cannot handle token prior to '.date('Y-m-d\TH:i:sO', $payload->iat));
        }
        if(isset($payload->exp) && ($timestamp - self::$leeway) >= $payload->exp){
            throw new UnexpectedValueException('Expired token');
        }
        return $payload;
    }

    /**
     * @param array $payload
     * @param string $key
     * @param string $alg
     * @param string|null $keyId
     * @param array|null $head
     * @return string
     * Converts and signs a PHP object or array into a JWT string
     */
    public static function encode(array $payload, string $key, string $alg = 'HS256', ?string $keyId = null, ?array $head = null):string
    {
        $header         = ['typ' => 'JWT', 'alg' => $alg];
        if($keyId !== null) $header['kid'] = $keyId;
        if(is_array($head)) $header = array_merge($head, $header);
        $segments       = [];
        $segments[]     = self::urlsafeB64Encode(self::jsonEncode($header));
        $segments[]     = self::urlsafeB64Encode(self::jsonEncode($payload));
        $signingInput   = implode('.', $segments);
        $signature      = self::sign($signingInput, $key, $alg);
        $segments[]     = self::urlsafeB64Encode($signature);
        return implode('.', $segments);
    }

    /**
     * @param string $msg
     * @param string $key
     * @param string $alg
     * @return string
     * Sign a string with a given key and algorithm
     */
    public static function sign(string $msg, string $key, string $alg = 'HS256'):string
    {
        if(empty(self::$supportedAlgs[$alg])){
            throw new DomainException('Algorithm not supported');
        }
        list($function, $algorithm) = self::$supportedAlgs[$alg];
        switch ($function) {
            case 'hash_hmac':{
                return hash_hmac($algorithm, $msg, $key, true);
            }
            case 'openssl':{
                $signature  = '';
                $success    = openssl_sign($msg, $signature, $key, $algorithm);
                if(!$success){
                    throw new DomainException("OpenSSL unable to sign data");
                }else{
                    if($alg === 'ES256') $signature = self::signatureFromDER($signature, 256);
                    return $signature;
                }
            }
        }
        throw new DomainException('Algorithm not supported');
    }

    /**
     * @param string $msg
     * @param string $signature
     * @param $key
     * @param string $alg
     * @return bool
     * Verify a signature with the message, key and method. Not all methods are symmetric, so we must have a separate verify and sign method
     */
    private static function verify(string $msg, string $signature, $key, string $alg):bool
    {
        if(empty(self::$supportedAlgs[$alg])){
            throw new DomainException('Algorithm not supported');
        }
        list($function, $algorithm) = self::$supportedAlgs[$alg];
        switch ($function){
            case 'openssl':{
                $success    = openssl_verify($msg, $signature, $key, $algorithm);
                if($success === 1){
                    return true;
                }elseif ($success === 0){
                    return false;
                }
                // returns 1 on success, 0 on failure, -1 on error.
                throw new DomainException('OpenSSL error: ' . openssl_error_string());
            }
            case 'hash_hmac':
            default:{
                $hash   = hash_hmac($algorithm, $msg, $key, true);
                if(function_exists('hash_equals')){
                    return hash_equals($signature, $hash);
                }
                $len    = min(self::safeStrlen($signature), self::safeStrlen($hash));
                $status = 0;
                for ($i = 0; $i < $len; $i++) {
                    $status |= (ord($signature[$i]) ^ ord($hash[$i]));
                }
                $status |= (self::safeStrlen($signature) ^ self::safeStrlen($hash));
                return ($status === 0);
            }
        }
    }

    /**
     * @param string $input
     * @return mixed
     * Decode a JSON string into a PHP object
     */
    public static function jsonDecode(string $input)
    {
        $obj    = json_decode($input, false, 512, JSON_BIGINT_AS_STRING);
        if ($errno = json_last_error()) {
            self::handleJsonError($errno);
        } elseif ($obj === null && $input !== 'null') {
            throw new DomainException('Null result with non-null input');
        }
        return $obj;
    }

    /**
     * @param $input
     * @return string
     * Encode a PHP object into a JSON string
     */
    public static function jsonEncode($input):string
    {
        $json       = json_encode($input);
        if ($errno = json_last_error()) {
            self::handleJsonError($errno);
        } elseif ($json === 'null' && $input !== null) {
            throw new DomainException('Null result with non-null input');
        }
        return $json;
    }

    /**
     * @param string $input
     * @return string
     * Decode a string with URL-safe Base64
     */
    public static function urlsafeB64Decode(string $input):string
    {
        $remainder  = strlen($input) % 4;
        if($remainder){
            $padlen = 4 - $remainder;
            $input .= str_repeat('=', $padlen);
        }
        $result = base64_decode(strtr($input, '-_', '+/'));
        return is_string($result) ? $result : '';
    }
    
    /**
     * @param string $input
     * @return string
     * Encode a string with URL-safe Base64
     */
    public static function urlsafeB64Encode(string $input):string
    {
        return str_replace('=', '', strtr(base64_encode($input), '+/', '-_'));
    }
    
    /**
     * @param int $errno
     * Helper method to create a JSON error
     */
    private static function handleJsonError(int $errno)
    {
        $messages = [
            JSON_ERROR_DEPTH            => 'Maximum stack depth exceeded',
            JSON_ERROR_STATE_MISMATCH   => 'Invalid or malformed JSON',
            JSON_ERROR_CTRL_CHAR        => 'Unexpected control character found',
            JSON_ERROR_SYNTAX           => 'Syntax error, malformed JSON',
            JSON_ERROR_UTF8             => 'Malformed UTF-8 characters',
        ];
        throw new DomainException($messages[$errno] ?? 'Unknown JSON error: ' . $errno);
    }

    /**
     * @param string $str
     * @return int
     * Get the number of bytes in cryptographic strings
     */
    private static function safeStrlen(string $str):int
    {
        if (function_exists('mb_strlen')) return mb_strlen($str, '8bit');
        return strlen($str);
    }

    /**
     * @param string $sig
     * @return string
     * Convert an ECDSA signature to an ASN.1 DER sequence
     */
    private static function signatureToDER(string $sig):string
    {
        list($r, $s) = str_split($sig, (int) (strlen($sig) / 2));
        $r          = ltrim($r, "\x00");
        $s          = ltrim($s, "\x00");
        if(ord($r[0]) > 0x7f) $r = "\x00".$r;
        if(ord($s[0]) > 0x7f) $s = "\x00".$s;
        return self::encodeDER(self::ASN1_SEQUENCE, self::encodeDER(self::ASN1_INTEGER, $r).self::encodeDER(self::ASN1_INTEGER, $s));
    }

    /**
     * @param int $type
     * @param string $value
     * @return string
     * Encodes a value into a DER object
     */
    private static function encodeDER(int $type, string $value):string
    {
        $tagHeader = 0;
        if($type === self::ASN1_SEQUENCE) $tagHeader |= 0x20;
        $der    = chr($tagHeader | $type);
        $der    .= chr(strlen($value));
        return $der.$value;
    }

    /**
     * @param string $der
     * @param int $keySize
     * @return string
     * Encodes signature from a DER object
     */
    private static function signatureFromDER(string $der, int $keySize):string
    {
        list($offset, $_) = self::readDER($der);
        list($offset, $r) = self::readDER($der, $offset);
        list($offset, $s) = self::readDER($der, $offset);
        unset($_, $offset);
        $r = ltrim($r, "\x00");
        $s = ltrim($s, "\x00");
        $r = str_pad($r, $keySize / 8, "\x00", STR_PAD_LEFT);
        $s = str_pad($s, $keySize / 8, "\x00", STR_PAD_LEFT);
        return $r.$s;
    }

    /**
     * @param string $der
     * @param int $offset
     * @return array
     * Reads binary DER-encoded data and decodes into a single object
     */
    private static function readDER(string $der, int $offset = 0):array
    {
        $pos            = $offset;
        $size           = strlen($der);
        $constructed    = (ord($der[$pos]) >> 5) & 0x01;
        $type           = ord($der[$pos++]) & 0x1f;
        $len            = ord($der[$pos++]);
        if ($len & 0x80) {
            $n          = $len & 0x1f;
            $len        = 0;
            while ($n-- && $pos < $size) {
                $len    = ($len << 8) | ord($der[$pos++]);
            }
        }
        if($type == self::ASN1_BIT_STRING){
            $pos++;
            $data   = substr($der, $pos, $len - 1);
            $pos    += $len - 1;
        }elseif (!$constructed){
            $data   = substr($der, $pos, $len);
            $pos    += $len;
        }else{
            $data   = null;
        }
        return [$pos, $data];
    }
}